User
field in Roles
table using SSMS or Seed data in DbContext filePersonId
is nullable in Account
, so you can add fields related to "Person" later after registrationAccountRoles
in Accountpublic virtual ICollection<AccountRole> AccountRoles { get; set; }
AccountRole
public virtual Role Role { get; set; }
public virtual Account Account{ get; set; }
builder.HasOne<Account>(ar => ar.Account)
.WithMany(a => a.AccountRoles)
.HasForeignKey(ar => ar.AccountId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Role>(ar => ar.Role)
.WithMany()
.HasForeignKey(ar => ar.RoleId)
.OnDelete(DeleteBehavior.Restrict);
๐ Suggested Folder: ApplicationLayer/DTOs/[RelatedFolder]
AccountDto
to expose relevant account information:public class AccountDto
{
public long Id { get; set; }
public required string PhoneNumber { get; set; }
public required string Password { set; get; }
public string? Email { get; set; }
public long? PersonId { get; set; }
public List<string> Roles { get; set; }
}
public class AuthResponseDto
{
public long Id { get; set; }
public string Token { get; set; } = null!;
public string PhoneNumber { get; set; } = null!;
public List<string> Roles { get; set; }
}
public class LoginRequestDto
{
public string PhoneNumber { get; set; } = null!;
public string Password { get; set; } = null!;
}
public class RegisterRequestDto
{
[Required(ErrorMessage = "Phone number is required.")]
[Phone(ErrorMessage = "Phone number format is invalid.")]
public required string PhoneNumber { get; set; }
[Required(ErrorMessage = "Password is required.")]
[MinLength(6, ErrorMessage = "Password must be at least 6 characters long.")]
public required string Password { get; set; }
[Compare("Password", ErrorMessage = "Passwords do not match.")]
public required string ConfirmPassword { get; set; }
}
[Required]
, [Phone]
, [MinLength]
, [Compare]
on the DTO do?These are Data Annotations from System.ComponentModel.DataAnnotations
.
Theyโre used by:
[ApiController]
attributeWhat happens:
If your controller is marked with [ApiController]
, ASP.NET Core will automatically validate the DTO against these annotations before entering your action method.
Example:
[ApiController]
public class AuthController : ControllerBase
Then this:
[HttpPost("register")]
public async Task<IActionResult> Register(RegisterRequestDto dto)
If dto.PhoneNumber
is missing, it wonโt even run your logic, and will return a 400 Bad Request
with validation errors.
โWhy are these here if my frontend is separate?โ
โ Answer: They're still useful:
Frontend validation is for user experience, not security.
โ Both.
Backend is the source of truth.
Frontend can be bypassed (e.g., Postman).
In the backend, you can either:
[MinLength(6)]
if (dto.Password.Length < 6)
return BadRequest("Password must be at least 6 characters long.");
โ Yes โ if you're doing password comparison in backend.
[Compare("Password")]
will validate if ConfirmPassword
matches.You can skip sending ConfirmPassword to backend and just validate in frontend if youโre confident your frontend handles it.
But again: if someone sends malformed input manually (e.g., via Postman), backend should defend.
๐ก Best practice:
ConfirmPassword
in frontend (UX)[Compare]
for auto-validationโ YES โ it is OK and standard to send raw password in the login/signup request.
Why?
If your DTO looks like this:
public class RegisterRequestDto
{
[Required(ErrorMessage = "Phone number is required.")]
[Phone(ErrorMessage = "Phone number format is invalid.")]
public string PhoneNumber { get; set; }
[Required(ErrorMessage = "Password is required.")]
[MinLength(6, ErrorMessage = "Password must be at least 6 characters long.")]
public string Password { get; set; }
[Compare("Password", ErrorMessage = "Passwords do not match.")]
public string ConfirmPassword { get; set; }
}
And the frontend sends this:
{
"phoneNumber": "",
"password": "123",
"confirmPassword": "abc"
}
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"PhoneNumber": [
"Phone number is required."
],
"Password": [
"Password must be at least 6 characters long."
],
"ConfirmPassword": [
"Passwords do not match."
]
}
}
This is thanks to [ApiController]
on your controller class. The framework uses the ModelState and returns errors in a structured way.
MappingProfile
with the following mappings:CreateMap<Account, AccountDto>()
.ForMember(dest => dest.Roles, opt => opt.MapFrom(src => src.AccountRoles.Select(x=>x.Role.Title)));
CreateMap<AccountDto, Account>()
.ForMember(dest => dest.AccountRoles, opt => opt.Ignore());
PasswordHasher.cs
public static class PasswordHasher
{
public static string HashPassword(string password)
{
byte[] salt = RandomNumberGenerator.GetBytes(16);
var pbkdf2 = new Rfc2898DeriveBytes(password, salt, 100000, HashAlgorithmName.SHA256);
byte[] hash = pbkdf2.GetBytes(32);
byte[] hashBytes = new byte[48];
Array.Copy(salt, 0, hashBytes, 0, 16);
Array.Copy(hash, 0, hashBytes, 16, 32);
return Convert.ToBase64String(hashBytes);
}
public static bool VerifyPassword(string password, string hashedPassword)
{
byte[] hashBytes = Convert.FromBase64String(hashedPassword);
byte[] salt = new byte[16];
Array.Copy(hashBytes, 0, salt, 0, 16);
var pbkdf2 = new Rfc2898DeriveBytes(password, salt, 100000, HashAlgorithmName.SHA256);
byte[] hash = pbkdf2.GetBytes(32);
for (int i = 0; i < 32; i++)
{
if (hashBytes[i + 16] != hash[i])
return false;
}
return true;
}
}
IAccountRepository
Task<Account> GetByPhoneNumberAsync(string phoneNumber);
Task AddAccountRoleAsync(AccountRole accountRole);
AccountRepository
public async Task AddAccountRoleAsync(AccountRole accountRole)
{
await DbContext.AccountRoles.AddAsync(accountRole);
}
public async Task<Account> GetByPhoneNumberAsync(string phoneNumber)
{
var user = await DbContext.Accounts.Include(x => x.AccountRoles).ThenInclude(x => x.Role).FirstOrDefaultAsync(x => x.PhoneNumber == phoneNumber);
return user;
}
Result.cs
Error MethodError
method to include error messages:public static Result<T> Error(T data, string errorMessage) => new() { Status = ResultStatus.Error, Data = data, ErrorMessage = errorMessage };
IAuthService.cs
and AuthService.cs
IAuthService
interfacepublic interface IAuthService
{
Task<Result<AuthResponseDto>> RegisterAsync(RegisterRequestDto request);
Task<Result<AuthResponseDto>> LoginAsync(LoginRequestDto request);
}
AuthService.cs
Use this project as a reference:
https://github.com/MehrdadShirvani/AlibabaClone-Backend/blob/develop/AlibabaClone.Application/Services/AuthService.cs
IAuthService
in Service in Program.cs
Program.cs
//...
builder.Services.AddScoped<IAuthService, AuthService>();
//...
Install these packages in the WebApi
(Presentation Layer) project:
Microsoft.AspNetCore.Authentication.JwtBearer
Microsoft.IdentityModel.Tokens
System.IdentityModel.Tokens.Jwt
appsettings.json
"Jwt": {
"Key": "[supersecretkeyyoustoresecurely]",
"Issuer": "[Issuer]",
"Audience": "MyAppUsers",
"ExpiryMinutes": 60
}
Note that you should fill the values as you wish - these are just samples
JwtSettings
and add the following methodpublic class JwtSettings
{
public string Key { get; set; } = null!;
public string Issuer { get; set; } = null!;
public string Audience { get; set; } = null!;
public int ExpiryMinutes { get; set; }
}
IJwtGenerator
and add the following methodstring GenerateToken(AuthResponseDto authResponseDto);
JwtGenerator
, implementing IJwtGenerator
use this project as a reference
https://github.com/MehrdadShirvani/AlibabaClone-Backend/blob/develop/AlibabaClone.WebAPI/Authentication/JwtGenerator.cs
JwtGenerator
servicebuilder.Services.AddScoped<IJwtGenerator, JwtGenerator>();
JwtSettings
from configurationbuilder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("Jwt"));
var jwtSettings = builder.Configuration.GetSection("Jwt").Get<JwtSettings>();
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtSettings.Issuer,
ValidateAudience = true,
ValidAudience = jwtSettings.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Key)),
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
};
});
builder.Services.AddAuthorization();
app.UseAuthentication
before app.UseAuthorization
app.UseAuthentication();
app.UseAuthorization();
Create AuthController
๐ Suggested Folder: WebApi/Controllers/AuthController.cs
Add Class and Constructor
private readonly IAuthService _authService;
private readonly IJwtGenerator _jwtGenerator;
public AuthController(IAuthService authService, IJwtGenerator jwtGenerator)
{
_authService = authService;
_jwtGenerator = jwtGenerator;
}
public async Task<IActionResult> Register(RegisterRequestDto request)
{
var result = await _authService.RegisterAsync(request);
if (!result.IsSuccess)
return BadRequest(result.ErrorMessage);
var token = _jwtGenerator.GenerateToken(result.Data);
var response = new AuthResponseDto
{
PhoneNumber = result.Data.PhoneNumber,
Roles = result.Data.Roles,
Token = token
};
return Ok(response);
}
[HttpPost("login")]
public async Task<IActionResult> Login(LoginRequestDto request)
{
var result = await _authService.LoginAsync(request);
if (!result.IsSuccess)
return Unauthorized(result.ErrorMessage);
var token = _jwtGenerator.GenerateToken(result.Data);
var response = new AuthResponseDto
{
PhoneNumber = result.Data.PhoneNumber,
Roles = result.Data.Roles,
Token = token
};
return Ok(response);
}
public class AccountController : ControllerBase
{
[Authorize(Roles = "User")]
[HttpGet("profile")]
public IActionResult GetProfile()
{
return Ok("Hi there, hello");
}
}